Skip to content

feat(rpc): add mining-transaction snapshot proofs - #7107

Open
PastaPastaPasta wants to merge 8 commits into
dashpay:developfrom
PastaPastaPasta:platform-sdk-compact-proof
Open

feat(rpc): add mining-transaction snapshot proofs#7107
PastaPastaPasta wants to merge 8 commits into
dashpay:developfrom
PastaPastaPasta:platform-sdk-compact-proof

Conversation

@PastaPastaPasta

@PastaPastaPasta PastaPastaPasta commented Jan 17, 2026

Copy link
Copy Markdown
Member

Issue being fixed or feature implemented

Allow Platform SDKs to authenticate newer quorum keys and EvoNode records from an independently pinned Core snapshot using compact ordinary certificate proofs. Implements the mining-transaction proof format in DIP #175.

What was done?

Each DASHNC02 link carries a ChainLock certificate and the complete next quorum's mining transaction with a transaction Merkle path. Consecutive X11 headers bridge unavailable ChainLocks at mining height. The final coinbase authenticates both roots; an optional bootstrap envelope opens the requested Platform quorum and up to fifteen eligible EvoNodes.

getquorumproofchain generates evidence from a checkpoint block hash and minimum target height. verifyquorumproofchain takes the full independently trusted snapshot, bounded binary proof, and optional freshness floor. Verification enforces canonical framing, strict certificate-height progress, BLS/X11 linkage, positional Merkle shape, and cumulative resource budgets. The wire and HTTP bootstrap response are limited to 1 MiB; certificates and total ancestor headers are each limited to 4,096.

getchainlockbyheight and proof generation read historical evidence from disk on demand. ChainLock code finds coinbase-carried certificates using exponential/binary search over consensus-monotonic certified heights, with a bounded cache for one request. The existing mined-commitment database locates mining transactions. Bounded in-memory LRU caches reuse successful Basic BLS certificate checks, canonical commitment parsing, historical signing-quorum selection and mining-block locations across requests and overlapping ranges. Keys bind the complete verification inputs or exact block history; selection cache misses check the active branch under cs_main, and mining-location hits must belong to the request chain. Every request still checks its checkpoint, ancestry, inclusion paths, roots and budgets. No additional index, startup scan, persistent proof manager, or block-processing hooks are needed. Required historical blocks must be retained; missing/pruned data is an explicit error. Construction uses a fixed chain view, performs bulk disk reads and verification outside cs_main, and checks the target carrier (or live signed block) is still active before returning. The final certificate can come from the existing ChainLock manager before a later coinbase embeds it; historical handoffs still come from disk.

The trust model assumes historically authenticated ChainLock quorums remain honest. This verifier does not reconstruct DKG, full Core consensus, or exact active signer eligibility. No consensus/signing rules change, trusted setup, proof VM, GPU, or new P2P messages are introduced.

How Has This Been Tested?

Locally built on Apple ARM64 with the repository's prebuilt dependencies: full no-wallet build with the experimental shared kernel and linked dash-chainstate, plus a full wallet-enabled build. All 40 selected cases in llmq_chainlock_tests, quorum_proofs_tests, and validation_chainstatemanager_tests pass. Coverage includes disk-backed historical lookup across repeated/skipped signatures, shorter-chain requests, missing block data, real testnet proof roundtrip and tampering, resource limits, and multiple-chainstate lifecycle behavior.

rpc_help.py and feature_quorum_proof_chain.py pass with and without wallet support, including positional/named CLI arguments, mixed HTTP batches, malformed input, freshness, and restart checks. feature_llmq_chainlocks.py passes real multi-node ChainLock creation, historical RPC lookup, and its existing reorg/restart checks. Cppcheck, Python flake8/mypy, format strings, circular dependency, assertion, test-suite-name, changed-line formatting, and whitespace checks pass.

The shared real testnet fixture is 3,469 raw proof bytes; the matching SDK bootstrap with one quorum and one EvoNode is 4,506 bytes. Mining-transaction reference encodings of real testnet 90/180/366-day histories measured 85,827/159,536/314,357 gzip bytes, before final record openings. These are testnet observations, not mainnet guarantees. Real mainnet/testnet archive RPC generation is now benchmarked at 90/180/366-day spans in the performance report. All 66 measured requests verified; byte-identical proofs were returned across stock, profiling and cold-block-file runs. On an Apple M4 Max SSD, the year-long mainnet proof took 5.52 s with cold block files (0.33 s in block loading), and testnet took 24.61 s (0.36 s in block loading). Mainnet proof sizes were 38,732 / 73,800 / 151,070 gzip bytes. The memoization follow-up compares 90 real archive requests across baseline, signature/parsing-only and final implementations. Repeated year requests fall from 5.37 to 0.91 s mainnet and 24.59 to 1.53 s testnet; the first six-month query after a year query takes 0.61 / 0.78 s. First requests after restart remain 5.25 / 23.69 s. Every proof and bootstrap is byte-identical to the baseline. The final implementation builds with and without wallet support, passes all 51 selected unit tests plus proof RPC/help functional tests in both builds, and adds warmed-cache mutation and concurrent-verification regressions. The full-stack integration report records real mainnet/testnet Core → quorum-list-server → native SDK validation, including live Platform epoch queries with the default verified provider, year-long histories, and six rejected HTTP fault cases. This exposed and fixed SDK quorum-hash byte order, RPC transport timeouts, and availability of a final live ChainLock before its later coinbase carrier. Browser-to-live-server, Swift/FFI, the DAPI proof-serving route, and production deployment remain outside this run.

Breaking Changes

None to released interfaces or consensus. Generation supports mainnet/testnet; independent fixture verification is also testable on regtest.

Checklist:

  • I have performed a self-review of my own code
  • I have commented my code, particularly in hard-to-understand areas
  • I have added or updated relevant unit/integration/functional/e2e tests
  • I have made corresponding changes to the documentation
  • I have assigned this pull request to a milestone

@github-actions

github-actions Bot commented Jan 17, 2026

Copy link
Copy Markdown

✅ No Merge Conflicts Detected

This PR currently has no conflicts with other open PRs.

@coderabbitai

coderabbitai Bot commented Jan 17, 2026

Copy link
Copy Markdown

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Adds a new LLMQ quorum-proof subsystem: public headers and implementation (src/llmq/quorumproofs.{h,cpp} and src/llmq/quorumproofdata.h) implementing CQuorumProofManager with chainlock indexing, Merkle proof construction/verification, proof-chain build/verify APIs, EvoDB persistence and migrations. Wires the manager into LLMQContext, CQuorumBlockProcessor, CSpecialTxProcessor/CChainstateHelper, and init migration logic. Exposes RPCs (getchainlockbyheight, getquorumproofchain, verifyquorumproofchain), new quorum-scanning/selection helpers, fast-path mined-commitment access, and adds unit, regression, and functional tests plus test runner entries.

Sequence Diagram(s)

sequenceDiagram
    participant Client as RPC Client
    participant RPC as getquorumproofchain
    participant ProofMgr as CQuorumProofManager
    participant EvoDB as CEvoDB
    participant QBProc as CQuorumBlockProcessor
    participant Chain as CChain

    Client->>RPC: Call getquorumproofchain(checkpoint, target)
    RPC->>ProofMgr: BuildProofChain(checkpoint, target, qman, chain, block_man)
    ProofMgr->>EvoDB: Read stored quorum/coinbase proof data
    ProofMgr->>QBProc: Fetch mined commitments / block metadata
    ProofMgr->>Chain: Traverse headers between checkpoint and targets
    ProofMgr->>ProofMgr: Construct Merkle & coinbase proofs per step
    ProofMgr-->>RPC: Return QuorumProofChain (JSON + hex)
    RPC-->>Client: Respond with proof
Loading
sequenceDiagram
    participant Client as RPC Client
    participant RPC as verifyquorumproofchain
    participant ProofMgr as CQuorumProofManager
    participant EvoDB as CEvoDB
    participant QBProc as CQuorumBlockProcessor
    participant Crypto as BLS Crypto

    Client->>RPC: Call verifyquorumproofchain(checkpoint, proof, expected)
    RPC->>ProofMgr: VerifyProofChain(checkpoint, proof, expected_llmq, expected_quorumHash)
    ProofMgr->>ProofMgr: Check header continuity, sizes, limits
    loop per proof element
        ProofMgr->>EvoDB: Optionally validate chainlock index entries
        ProofMgr->>QBProc: Verify commitments and Merkle roots against block data
        ProofMgr->>Crypto: Verify chainlock/quorum signatures and public keys
    end
    ProofMgr-->>RPC: Return QuorumProofVerifyResult (valid/error)
    RPC-->>Client: Respond with verification result
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 39.36% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the main feature: RPC support for mining-transaction snapshot proofs. It is concise and related to the quorum proof implementation and new proof RPCs.
Description check ✅ Passed The description directly explains the quorum proof feature, RPCs, verification behavior, security limits, testing, performance, and compatibility impact.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Fix all issues with AI agents
In `@src/llmq/quorumproofs.cpp`:
- Around line 494-499: The code uses
step.quorum->m_quorum_base_block_index->GetAncestor(step.chainlockHeight) which
fails when step.chainlockHeight is ahead of the base block height; replace this
lookup with active_chain[step.chainlockHeight] (using the active_chain
parameter) to safely access the block at chainlockHeight with bounds checking;
update the block lookup in the function where FindChainlockCoveringBlock results
are used so it references active_chain[step.chainlockHeight] instead of
GetAncestor on m_quorum_base_block_index.

In `@src/llmq/quorumproofs.h`:
- Around line 1-6: Run the project's clang-format on the changed header to
resolve CI formatting failures: apply clang-format (or clang-format-diff) to
src/llmq/quorumproofs.h and reformat the file so it matches the repository style
(fix whitespace, alignment, include ordering, and brace/indent rules) and
re-stage the changes; the header guard BITCOIN_LLMQ_QUORUMPROOFS_H can be used
to locate the file and verify the corrected formatting.
🧹 Nitpick comments (7)
src/rpc/quorums.cpp (1)

1311-1330: Validate input object structure in ParseCheckpointFromRPC.

The helper function directly accesses keys like checkpointObj["block_hash"] and checkpointObj["chainlock_quorums"] without first verifying they exist. If a user provides a malformed checkpoint object missing required keys, this will throw a less informative exception.

Consider adding existence checks or using .find() with appropriate error messages for better RPC error handling.

♻️ Suggested improvement
 static llmq::QuorumCheckpoint ParseCheckpointFromRPC(const UniValue& checkpointObj)
 {
+    if (!checkpointObj.exists("block_hash") || !checkpointObj.exists("height") || 
+        !checkpointObj.exists("chainlock_quorums")) {
+        throw JSONRPCError(RPC_INVALID_PARAMETER, "Checkpoint must contain block_hash, height, and chainlock_quorums");
+    }
+
     llmq::QuorumCheckpoint checkpoint;
     checkpoint.blockHash = ParseHashV(checkpointObj["block_hash"], "block_hash");
     // ... rest unchanged
test/functional/feature_quorum_proof_chain.py (2)

49-78: Consider catching specific JSONRPCException instead of broad Exception.

The broad except Exception catches can mask unexpected failures. For RPC error handling in tests, catching JSONRPCException specifically would be more precise and help detect actual test failures vs expected "not found" responses.

♻️ Suggested improvement
+from test_framework.authproxy import JSONRPCException
+
 # In test_chainlock_index:
         for h in range(tip_height, 200, -1):
             try:
                 cl_info = self.nodes[0].getchainlockbyheight(h)
                 # ... success handling
-            except Exception:
+            except JSONRPCException:
                 continue

115-142: Consider removing or using the build_checkpoint helper.

The build_checkpoint method is defined but never called in the test. If it's intended for future use with getquorumproofchain/verifyquorumproofchain tests, consider either:

  1. Adding tests that exercise these RPCs using this helper, or
  2. Adding a TODO comment explaining the intended future use

Currently, the test only covers getchainlockbyheight but not the proof chain generation/verification RPCs.

Would you like me to help draft additional test cases for getquorumproofchain and verifyquorumproofchain RPCs?

src/test/quorum_proofs_tests.cpp (1)

199-224: Consider adding FromJson roundtrip verification.

The test verifies ToJson output structure but doesn't complete the roundtrip by parsing with FromJson. Consider adding a full roundtrip test to ensure JSON serialization is bidirectional.

💡 Suggested enhancement
     BOOST_CHECK_EQUAL(json["height"].getInt<int>(), 1000);
+
+    // Verify FromJson roundtrip
+    llmq::QuorumCheckpoint parsed = llmq::QuorumCheckpoint::FromJson(json);
+    BOOST_CHECK(parsed.blockHash == checkpoint.blockHash);
+    BOOST_CHECK_EQUAL(parsed.height, checkpoint.height);
+    BOOST_CHECK_EQUAL(parsed.chainlockQuorums.size(), checkpoint.chainlockQuorums.size());
 }
src/llmq/quorumproofs.cpp (2)

158-178: Consider adding JSON field existence validation.

FromJson directly accesses JSON fields without checking existence first. If a caller provides malformed JSON missing required fields, the error message may be unclear. Consider validating field presence.

💡 Suggested improvement
 QuorumCheckpoint QuorumCheckpoint::FromJson(const UniValue& obj)
 {
     QuorumCheckpoint checkpoint;
 
+    if (!obj.exists("blockHash") || !obj.exists("height") || !obj.exists("chainlockQuorums")) {
+        throw std::runtime_error("Missing required fields in checkpoint JSON");
+    }
+
     checkpoint.blockHash = uint256S(obj["blockHash"].get_str());
     checkpoint.height = obj["height"].getInt<int32_t>();

230-253: Consider consolidating duplicated merkle proof verification logic.

The static VerifyMerkleProof function duplicates the logic in QuorumMerkleProof::Verify. Consider having one call the other to reduce code duplication.

💡 Suggested refactor
 static bool VerifyMerkleProof(const uint256& leafHash,
                                const std::vector<uint256>& merklePath,
                                const std::vector<bool>& merklePathSide,
                                const uint256& expectedRoot)
 {
-    if (merklePath.size() != merklePathSide.size()) {
-        return false;
-    }
-
-    if (merklePath.size() > MAX_MERKLE_PATH_LENGTH) {
-        return false;
-    }
-
-    uint256 current = leafHash;
-    for (size_t i = 0; i < merklePath.size(); ++i) {
-        if (merklePathSide[i]) {
-            current = Hash(current, merklePath[i]);
-        } else {
-            current = Hash(merklePath[i], current);
-        }
-    }
-
-    return current == expectedRoot;
+    QuorumMerkleProof proof;
+    proof.merklePath = merklePath;
+    proof.merklePathSide = merklePathSide;
+    return proof.Verify(leafHash, expectedRoot);
 }
src/llmq/quorumproofs.h (1)

209-222: Move DB_CHAINLOCK_BY_HEIGHT to an anonymous namespace or make it inline.

The static const std::string in a header creates a separate copy in each translation unit that includes this header. For a string constant used as a DB key, this wastes memory. Consider using inline constexpr (C++17) or moving to an anonymous namespace in the .cpp file.

💡 Suggested fix

Move to the .cpp file within an anonymous namespace:

// In quorumproofs.cpp
namespace {
const std::string DB_CHAINLOCK_BY_HEIGHT = "q_clh";
} // anonymous namespace

Or if it must remain in the header (C++17):

-static const std::string DB_CHAINLOCK_BY_HEIGHT = "q_clh";
+inline constexpr std::string_view DB_CHAINLOCK_BY_HEIGHT = "q_clh";

Comment thread src/llmq/quorumproofs.cpp Outdated
Comment thread src/llmq/quorumproofs.h Outdated
Comment on lines +1 to +6
// Copyright (c) 2025 The Dash Core developers
// Distributed under the MIT/X11 software license, see the accompanying
// file COPYING or http://www.opensource.org/licenses/mit-license.php.

#ifndef BITCOIN_LLMQ_QUORUMPROOFS_H
#define BITCOIN_LLMQ_QUORUMPROOFS_H

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Fix clang-format issues as indicated by CI pipeline.

The CI pipeline reports clang-format differences. Run clang-format-diff to fix formatting.

🧰 Tools
🪛 GitHub Actions: Clang Diff Format Check

[error] 1-1: Clang format differences found in src/llmq/quorumproofs.h. Run the clang-format-diff tool to fix formatting.

🤖 Prompt for AI Agents
In `@src/llmq/quorumproofs.h` around lines 1 - 6, Run the project's clang-format
on the changed header to resolve CI formatting failures: apply clang-format (or
clang-format-diff) to src/llmq/quorumproofs.h and reformat the file so it
matches the repository style (fix whitespace, alignment, include ordering, and
brace/indent rules) and re-stage the changes; the header guard
BITCOIN_LLMQ_QUORUMPROOFS_H can be used to locate the file and verify the
corrected formatting.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Fix all issues with AI agents
In `@src/llmq/quorumproofs.cpp`:
- Around line 582-670: The header-continuity check against proof.headers assumes
consecutive blocks but BuildProofChain only supplies commitment blocks, and the
code does not tie each quorum proof to the chainlock-signed block; update the
proof verification so each QuorumCommitmentProof is anchored to its chainlock:
for each qProof, require chainlock.blockHash == header.GetHash() (using the
header selected for that qProof) instead of enforcing proof.headers are strictly
consecutive, or alternatively modify BuildProofChain to include all intermediate
headers up to chainlock.nHeight and keep the continuity check; ensure you change
the loop that checks proof.headers continuity and the place that fetches const
CBlockHeader& header = proof.headers[proofIdx] so it selects the header matching
chainlock.blockHash (or add headers in BuildProofChain) and verify
chainlock.blockHash equality before any merkle/signature checks.

In `@src/test/quorum_proofs_regression_tests.cpp`:
- Around line 147-188: The test adds two headers (chain.headers) but only one
quorum proof (chain.quorumProofs), causing VerifyProofChain to abort on a
headers/ proofs count mismatch; add a second llmq::QuorumCommitmentProof for
header2 so counts match. Create another qProof (copying the first
llmq::QuorumCommitmentProof setup used for qProof), give it a distinct
commitment.quorumHash (e.g., uint256::THREE or similar), set
qProof.chainlockIndex consistent with existing clEntry usage, assign a
coinbaseTx (CMutableTransaction mtx like before) and push_back this second
qProof into chain.quorumProofs so chain.headers.size() ==
chain.quorumProofs.size().
♻️ Duplicate comments (1)
src/llmq/quorumproofs.cpp (1)

496-499: Chainlock block lookup can return nullptr when the chainlock height is ahead of the base block.

Line 496-499 uses GetAncestor(...), which only walks backward; for chainlock heights greater than the quorum base block height this yields nullptr. Prefer looking up by height on active_chain (as already flagged).

🧹 Nitpick comments (2)
src/llmq/quorumproofs.cpp (1)

59-83: Avoid duplicate merkle-proof verification logic.

Line 59-83 and Line 231-254 implement the same hashing loop/DoS checks. Consider delegating to a single helper to prevent drift.

Also applies to: 231-254

test/functional/feature_quorum_proof_chain.py (1)

55-76: Avoid swallowing unexpected RPC errors.

Bare except Exception masks real failures in the scan loops; catching JSONRPCException keeps intent while preserving unexpected errors.

♻️ Suggested refinement (apply similarly to other loops)
-from test_framework.util import assert_equal, assert_raises_rpc_error
+from test_framework.util import assert_equal, assert_raises_rpc_error
+from test_framework.authproxy import JSONRPCException
@@
-            except Exception:
-                continue
+            except JSONRPCException as e:
+                if e.error.get("code") != -5:
+                    raise
+                self.log.debug(f"Height {h} not chainlocked yet: {e}")
+                continue
@@
-            except Exception:
-                continue
+            except JSONRPCException as e:
+                if e.error.get("code") != -5:
+                    raise
+                self.log.debug(f"Height {h} not chainlocked yet: {e}")
+                continue
@@
-        try:
-            cl_quorums = self.nodes[0].quorum("list", llmq_type)
-        except Exception:
-            # If quorum list fails, try with different type
-            cl_quorums = []
+        try:
+            cl_quorums = self.nodes[0].quorum("list", llmq_type)
+        except JSONRPCException as e:
+            self.log.debug(f"quorum list failed for type {llmq_type}: {e}")
+            cl_quorums = []
@@
-            except Exception:
-                continue
+            except JSONRPCException as e:
+                self.log.debug(f"quorum info failed for {qhash}: {e}")
+                continue

Also applies to: 86-95, 120-136

Comment thread src/llmq/quorumproofs.cpp Outdated
Comment on lines +147 to +188
// Create proof chain with DISCONTINUOUS headers
llmq::QuorumProofChain chain;

CBlockHeader header1;
header1.nVersion = 1;
header1.hashPrevBlock = uint256::ZERO;
header1.hashMerkleRoot = uint256::ONE;
header1.nTime = 1234567890;
header1.nBits = 0x1d00ffff;
header1.nNonce = 1;

CBlockHeader header2;
header2.nVersion = 1;
// BUG TRIGGER: prevBlockHash does NOT match header1.GetHash()
header2.hashPrevBlock = uint256::TWO; // Should be header1.GetHash()
header2.hashMerkleRoot = uint256::TWO;
header2.nTime = 1234567891;
header2.nBits = 0x1d00ffff;
header2.nNonce = 2;

chain.headers.push_back(header1);
chain.headers.push_back(header2);

// Add chainlock
llmq::ChainlockProofEntry clEntry;
clEntry.nHeight = 100;
clEntry.blockHash = header1.GetHash();
clEntry.signature = sk.Sign(clEntry.blockHash, false);
chain.chainlocks.push_back(clEntry);

// Add quorum proof
llmq::QuorumCommitmentProof qProof;
qProof.commitment.llmqType = Consensus::LLMQType::LLMQ_TEST;
qProof.commitment.quorumHash = uint256::TWO;
qProof.chainlockIndex = 0;

CMutableTransaction mtx;
mtx.nVersion = 3;
mtx.nType = TRANSACTION_COINBASE;
qProof.coinbaseTx = MakeTransactionRef(mtx);
chain.quorumProofs.push_back(qProof);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Header-continuity test currently fails for header/proof count mismatch.

Line 167-188 adds 2 headers but only 1 quorum proof, so VerifyProofChain exits early with “Headers count does not match...” and the continuity check isn’t exercised.

🧪 Proposed fix to align header/proof counts
     qProof.coinbaseTx = MakeTransactionRef(mtx);
     chain.quorumProofs.push_back(qProof);
+    chain.quorumProofs.push_back(qProof); // keep size in sync with headers
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Create proof chain with DISCONTINUOUS headers
llmq::QuorumProofChain chain;
CBlockHeader header1;
header1.nVersion = 1;
header1.hashPrevBlock = uint256::ZERO;
header1.hashMerkleRoot = uint256::ONE;
header1.nTime = 1234567890;
header1.nBits = 0x1d00ffff;
header1.nNonce = 1;
CBlockHeader header2;
header2.nVersion = 1;
// BUG TRIGGER: prevBlockHash does NOT match header1.GetHash()
header2.hashPrevBlock = uint256::TWO; // Should be header1.GetHash()
header2.hashMerkleRoot = uint256::TWO;
header2.nTime = 1234567891;
header2.nBits = 0x1d00ffff;
header2.nNonce = 2;
chain.headers.push_back(header1);
chain.headers.push_back(header2);
// Add chainlock
llmq::ChainlockProofEntry clEntry;
clEntry.nHeight = 100;
clEntry.blockHash = header1.GetHash();
clEntry.signature = sk.Sign(clEntry.blockHash, false);
chain.chainlocks.push_back(clEntry);
// Add quorum proof
llmq::QuorumCommitmentProof qProof;
qProof.commitment.llmqType = Consensus::LLMQType::LLMQ_TEST;
qProof.commitment.quorumHash = uint256::TWO;
qProof.chainlockIndex = 0;
CMutableTransaction mtx;
mtx.nVersion = 3;
mtx.nType = TRANSACTION_COINBASE;
qProof.coinbaseTx = MakeTransactionRef(mtx);
chain.quorumProofs.push_back(qProof);
// Create proof chain with DISCONTINUOUS headers
llmq::QuorumProofChain chain;
CBlockHeader header1;
header1.nVersion = 1;
header1.hashPrevBlock = uint256::ZERO;
header1.hashMerkleRoot = uint256::ONE;
header1.nTime = 1234567890;
header1.nBits = 0x1d00ffff;
header1.nNonce = 1;
CBlockHeader header2;
header2.nVersion = 1;
// BUG TRIGGER: prevBlockHash does NOT match header1.GetHash()
header2.hashPrevBlock = uint256::TWO; // Should be header1.GetHash()
header2.hashMerkleRoot = uint256::TWO;
header2.nTime = 1234567891;
header2.nBits = 0x1d00ffff;
header2.nNonce = 2;
chain.headers.push_back(header1);
chain.headers.push_back(header2);
// Add chainlock
llmq::ChainlockProofEntry clEntry;
clEntry.nHeight = 100;
clEntry.blockHash = header1.GetHash();
clEntry.signature = sk.Sign(clEntry.blockHash, false);
chain.chainlocks.push_back(clEntry);
// Add quorum proof
llmq::QuorumCommitmentProof qProof;
qProof.commitment.llmqType = Consensus::LLMQType::LLMQ_TEST;
qProof.commitment.quorumHash = uint256::TWO;
qProof.chainlockIndex = 0;
CMutableTransaction mtx;
mtx.nVersion = 3;
mtx.nType = TRANSACTION_COINBASE;
qProof.coinbaseTx = MakeTransactionRef(mtx);
chain.quorumProofs.push_back(qProof);
chain.quorumProofs.push_back(qProof); // keep size in sync with headers
🤖 Prompt for AI Agents
In `@src/test/quorum_proofs_regression_tests.cpp` around lines 147 - 188, The test
adds two headers (chain.headers) but only one quorum proof (chain.quorumProofs),
causing VerifyProofChain to abort on a headers/ proofs count mismatch; add a
second llmq::QuorumCommitmentProof for header2 so counts match. Create another
qProof (copying the first llmq::QuorumCommitmentProof setup used for qProof),
give it a distinct commitment.quorumHash (e.g., uint256::THREE or similar), set
qProof.chainlockIndex consistent with existing clEntry usage, assign a
coinbaseTx (CMutableTransaction mtx like before) and push_back this second
qProof into chain.quorumProofs so chain.headers.size() ==
chain.quorumProofs.size().

@PastaPastaPasta

Copy link
Copy Markdown
Member Author

current performance of proof generation:

==============================================
QUORUM PROOF CHAIN SCALING BENCHMARK

Target: LLMQ_100_67 quorum at height 2407200

Test | Time (s) | Steps | Size (bytes)
--------------------------+----------------------+----------------------+---------------------
~30 hours (683 blocks) | 0.125 ( -0.011) | 1 ( +0) | 1320 ( +0)
~7 days (4178 blocks) | 0.137 ( -0.022) | 4 ( +0) | 5489 ( -140)
~30 days (17430 blocks) | 0.187 ( -0.016) | 16 ( +0) | 22209 ( -307)
~6 months (103830 blocks) | 0.492 ( -0.080) | 89 ( +0) | 122701 ( +1114)
~12 months (210390 blocks) | 0.896 ( -0.194) | 181 ( +0) | 249494 ( +2139)

Legend: current (delta vs baseline) - negative delta = improvement

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/llmq/blockprocessor.cpp (1)

1-3: Fix clang-format diffs reported by CI.

The clang-format diff check is failing; please run the repo’s clang-format (or clang-format-diff) on the touched hunks and re‑stage.

🤖 Fix all issues with AI agents
In `@src/llmq/quorumproofs.cpp`:
- Around line 703-704: The ProofStep being pushed uses pProofBlock for the
mined-block pointer but documentation and fallback logic expect
ProofStep::pMinedBlockIndex to point to the block where the commitment was
mined; change the construction passed to proofSteps.push_back to use the mined
block pointer (the variable that represents the mined block for
currentCommitment) instead of pProofBlock so the fallback merkle proof builder
reads the correct block when cached data is missing (update the arguments around
proofSteps.push_back/currentCommitment to supply the mined-block index rather
than pProofBlock).
- Around line 53-85: ComputeSigningCommitmentIndex currently silently returns 0
when a rotated quorum's signer (computed from selectionHash and
llmq_params.signingActiveQuorumCount) is not found in commitments, which can
mis-attribute signers; update the rotated branch in
ComputeSigningCommitmentIndex to treat a missing quorumIndex as an explicit
failure: after computing signer, if no commitments[i].quorumIndex matches,
either throw a descriptive exception (e.g., std::runtime_error) or use an
explicit error return (e.g., return SIZE_MAX) and document that callers must
handle this error, and update any callers of ComputeSigningCommitmentIndex to
handle the new failure path; reference symbols: ComputeSigningCommitmentIndex,
llmq_params.useRotation, signingActiveQuorumCount, selectionHash,
commitments[i].quorumIndex.

In `@src/rpc/quorums.cpp`:
- Around line 1497-1499: The file src/rpc/quorums.cpp is failing clang-format;
run the formatter (e.g. clang-format-diff.py -p1 or clang-format) on the file
and apply the changes so the RPC registration lines for "evo" entries (functions
getchainlockbyheight, getquorumproofchain, verifyquorumproofchain) match the
project's style; update the file with the formatted whitespace/commas/alignment
and re-run CI to ensure clang-format diffs are resolved.
- Around line 1406-1470: The handler verifyquorumproofchain currently parses
expectedType and calls llmq_ctx.quorum_proof_manager->VerifyProofChain without
validating the LLMQ type; add a guard after parsing expectedType (the value
produced by static_cast<Consensus::LLMQType>(request.params[3].getInt<int>()))
to ensure it is a known/defined LLMQ type and return a clear RPC error
(valid=false with an explanatory message or throw RPC_INVALID_PARAMETER) if it
is not, before calling VerifyProofChain on proofChain/checkpoint.
♻️ Duplicate comments (1)
src/llmq/quorumproofs.cpp (1)

851-910: Header continuity check conflicts with proof layout; chainlocks aren’t anchored to headers.

The headers here are the commitment-mined blocks, which are typically not consecutive, so the strict prevBlockHash chain will reject multi-step proofs. Also, the chainlock signature isn’t tied to any header hash, so an unrelated header chain could still satisfy the merkle proofs. Consider either including intermediate headers up to the chainlock block, or anchoring each proof by requiring chainlock.blockHash == header.GetHash() and adjusting generation/verification accordingly.

🧹 Nitpick comments (2)
src/llmq/blockprocessor.cpp (1)

167-211: Consider consolidating the merkle-path helper.

This helper is duplicated in src/llmq/quorumproofs.cpp. Extracting a single shared implementation will reduce the risk of subtle divergence later.

src/llmq/quorumproofs.h (1)

8-18: Make the header self-contained for UniValue / std::map / std::string.

These types are used directly but the header doesn’t include their declarations. If they aren’t pulled transitively, this header won’t compile on its own. Consider adding explicit includes (or a UniValue forward declaration if you prefer to keep the include light).

🛠️ Proposed fix
 `#include` <llmq/types.h>
 `#include` <primitives/block.h>
 `#include` <primitives/transaction.h>
 `#include` <serialize.h>
 `#include` <uint256.h>
+#include <univalue.h>
 
+#include <map>
+#include <string>
 `#include` <set>
 `#include` <vector>

Comment thread src/llmq/quorumproofs.cpp Outdated
Comment thread src/llmq/quorumproofs.cpp Outdated
Comment thread src/rpc/quorums.cpp Outdated
Comment thread src/rpc/quorums.cpp

@knst knst left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some nits, haven't reviewed logic yet

Comment thread src/evo/specialtxman.cpp Outdated
// This prevents indexing chainlocks from blocks during a reorg
if (!fJustCheck && opt_cbTx->bestCLSignature.IsValid() &&
m_chainman.ActiveChain().Contains(pindex)) {
int32_t chainlockedHeight = pindex->nHeight - static_cast<int32_t>(opt_cbTx->bestCLHeightDiff) - 1;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: replace int32_t to int which is used all-over-codebase for height and height calculation

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

m_chainman.ActiveChain().Contains(pindex)) {

Is it relevant check? I think this if should be revised

Comment thread src/evo/specialtxman.cpp Outdated
// Remove chainlock index for this block's cbtx
if (block.vtx.size() > 0 && block.vtx[0]->nType == TRANSACTION_COINBASE) {
if (const auto opt_cbTx = GetTxPayload<CCbTx>(*block.vtx[0]); opt_cbTx && opt_cbTx->bestCLSignature.IsValid()) {
int32_t chainlockedHeight = pindex->nHeight - static_cast<int32_t>(opt_cbTx->bestCLHeightDiff) - 1;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same here for int32_t

Comment thread src/llmq/quorumproofs.cpp Outdated
@@ -0,0 +1,694 @@
// Copyright (c) 2025 The Dash Core developers

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: copyright year is 2026


class QuorumProofChainTest(DashTestFramework):
def set_test_params(self):
self.set_dash_test_params(5, 4)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe here should be 5, 3

3 masternodes and 2 regular nodes to test proof.

I also think, that this functional test should include a test with disconnected node to teset proof.

class QuorumProofChainTest(DashTestFramework):
def set_test_params(self):
self.set_dash_test_params(5, 4)
self.delay_v20_and_mn_rr(height=200)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why? I believe it's no needed

# Connect all nodes to node1 so that we always have the whole network connected
# Otherwise only masternode connections will be established between nodes
for i in range(2, len(self.nodes)):
self.connect_nodes(i, 1)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why? I believe it's no needed

Comment thread src/rpc/quorums.cpp Outdated
{RPCResult::Type::STR, "error", /* optional */ true, "Error message (if invalid)"},
}},
RPCExamples{
HelpExampleCli("verifyquorumproofchain", "'{...}' \"proof_hex\" \"quorum_hash\" 104")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

'{...}' "proof_hex" "quorum_hash" 10

Looks like placeholder instead RPC name method

Comment thread src/rpc/quorums.cpp Outdated
RPCResult::Type::OBJ, "", "",
{
{RPCResult::Type::BOOL, "valid", "Whether the proof is valid"},
{RPCResult::Type::STR_HEX, "quorum_public_key", /* optional */ true, "Verified public key (if valid)"},

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
{RPCResult::Type::STR_HEX, "quorum_public_key", /* optional */ true, "Verified public key (if valid)"},
{RPCResult::Type::STR_HEX, "quorum_public_key", /*optional*=/ true, "Verified public key (if valid)"},

same for other instances

Comment thread src/rpc/quorums.cpp Outdated
const UniValue& q = quorumsArr[i];
llmq::QuorumCheckpoint::QuorumEntry entry;
entry.quorumHash = ParseHashV(q["quorum_hash"], "quorum_hash");
entry.quorumType = static_cast<Consensus::LLMQType>(q["quorum_type"].getInt<int>());

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
entry.quorumType = static_cast<Consensus::LLMQType>(q["quorum_type"].getInt<int>());
entry.quorumType = static_cast<Consensus::LLMQType>(q["quorum_type"].getInt<uint8_t>());

because enum class LLMQType : uint8_t

Comment thread src/rpc/quorums.cpp Outdated
Comment on lines +1287 to +1294
{
LOCK(cs_main);
const CBlockIndex* pindex = chainman.ActiveChain()[height];
if (pindex == nullptr) {
throw JSONRPCError(RPC_INVALID_ADDRESS_OR_KEY, "Block not found at height");
}
blockHash = pindex->GetBlockHash();
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
{
LOCK(cs_main);
const CBlockIndex* pindex = chainman.ActiveChain()[height];
if (pindex == nullptr) {
throw JSONRPCError(RPC_INVALID_ADDRESS_OR_KEY, "Block not found at height");
}
blockHash = pindex->GetBlockHash();
}
const CBlockIndex* pindex = WITH_LOCK(::cs_main, return chainman.ActiveChain()[height]);
if (pindex == nullptr) {
throw JSONRPCError(RPC_INVALID_ADDRESS_OR_KEY, "Block not found at height");
}
blockHash = pindex->GetBlockHash();

no need to keep cs_main for extra checks; pindex doesn't require cs_main to be kept

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@src/llmq/quorumproofs.cpp`:
- Around line 867-897: Ensure the header at proof.headers[proofIdx] actually
matches the chainlock's signed block hash by checking header.GetHash() ==
chainlock.blockHash before using it for merkle verification; if the check fails,
set result.error (e.g. "Header does not match chainlock block hash in proof %d")
and return result. Insert this validation immediately after obtaining const
CBlockHeader& header = proof.headers[proofIdx] and prior to any merkle proof
verifications (coinbase and quorum commitment).
🧹 Nitpick comments (7)
src/llmq/quorumproofs.cpp (3)

1-1: Nit: Copyright year should be 2026.

As noted in a past review comment, the current date is January 2026, but the copyright header says 2025.

🔧 Suggested fix
-// Copyright (c) 2025 The Dash Core developers
+// Copyright (c) 2026 The Dash Core developers

294-338: Consider extracting BuildMerkleProofPath to a shared utility.

This function is duplicated verbatim from src/llmq/blockprocessor.cpp (lines 170-210). Consider moving it to a shared header (e.g., src/consensus/merkle.h or a new src/llmq/merkle_utils.h) to avoid duplication and ensure both implementations stay in sync.


1156-1160: Progress percentage calculation is a rough estimate.

The progress calculation indexed_count / 10 is a rough estimate that may not reflect actual progress. For example, if there are 500 total quorums, progress would cap at ~50% before completing. Consider tracking the total count upfront for accurate progress display, or document that this is an approximate indicator.

test/functional/feature_quorum_proof_chain.py (3)

114-141: build_checkpoint helper is defined but never called.

The build_checkpoint method is implemented but not used in run_test. If this is intended for future test expansion (e.g., testing getquorumproofchain/verifyquorumproofchain), consider adding a TODO comment. Otherwise, it could be removed to avoid dead code.


55-77: Consider catching specific JSONRPCException instead of bare Exception.

The try-except-continue pattern is common in test iteration, but catching a specific exception type would be more precise and avoid masking unexpected errors:

🔧 Suggested improvement
+from test_framework.authproxy import JSONRPCException
 ...
         for h in range(tip_height, 0, -1):
             try:
                 cl_info = self.nodes[0].getchainlockbyheight(h)
                 ...
                 return
-            except Exception:
+            except JSONRPCException:
                 continue

43-46: Consider adding tests for getquorumproofchain and verifyquorumproofchain RPCs.

The functional test covers getchainlockbyheight but not the proof chain generation/verification RPCs. The build_checkpoint helper suggests these were planned. Adding coverage would validate the end-to-end proof chain workflow.

Would you like me to help draft additional test cases for these RPCs?

src/rpc/quorums.cpp (1)

1376-1381: Inconsistent LLMQ type parsing between RPCs.

getquorumproofchain uses getInt<int>() (line 1377) while verifyquorumproofchain uses getInt<uint8_t>() (line 1462) for parsing the LLMQ type. Since LLMQType is enum class LLMQType : uint8_t, consider using uint8_t consistently:

🔧 Suggested fix
-    const Consensus::LLMQType targetType = static_cast<Consensus::LLMQType>(request.params[2].getInt<int>());
+    const Consensus::LLMQType targetType = static_cast<Consensus::LLMQType>(request.params[2].getInt<uint8_t>());

Comment thread src/llmq/quorumproofs.cpp Outdated
Comment thread src/llmq/blockprocessor.cpp Outdated
@@ -525,6 +648,61 @@ std::pair<CFinalCommitment, uint256> CQuorumBlockProcessor::GetMinedCommitment(C
return ret;
}

uint256 CQuorumBlockProcessor::GetMinedCommitmentTxHash(Consensus::LLMQType llmqType, const uint256& quorumHash) const

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the name is confusing, it's not a txhash, it's a hash of a serialized commitment message

Comment thread src/llmq/quorumproofs.cpp Outdated

// Read block from disk
CBlock block;
if (!ReadBlockFromDisk(block, pindex, chainparams.GetConsensus())) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be a critical error on non-pruned nodes. User must reindex.

Comment thread src/llmq/quorumproofs.cpp Outdated

// Try to extract CCbTx from coinbase
auto opt_cbtx = GetTxPayload<CCbTx>(*block.vtx[0]);
if (!opt_cbtx.has_value()) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should never fail too

Comment thread src/llmq/quorumproofs.cpp Outdated
int32_t chainlockedHeight = pindex->nHeight - static_cast<int32_t>(cbtx.bestCLHeightDiff) - 1;
const CBlockIndex* pChainlockedBlock = pindex->GetAncestor(chainlockedHeight);

if (pChainlockedBlock) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this pindex must be non-null

Comment thread src/llmq/quorumproofs.cpp Outdated
// CQuorumProofManager implementation
//

void CQuorumProofManager::IndexChainlock(int32_t chainlockedHeight, const uint256& blockHash,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

blockHash is not used

Comment thread src/rpc/quorums.cpp Outdated
{
{"checkpoint", RPCArg::Type::OBJ, RPCArg::Optional::NO, "Checkpoint data",
{
{"block_hash", RPCArg::Type::STR_HEX, RPCArg::Optional::NO, "Checkpoint block hash"},

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we should be able to calculate checkpoint from block_hash internally, this would simplify rpc params a lot

Comment thread src/rpc/quorums.cpp Outdated
{
{"checkpoint", RPCArg::Type::OBJ, RPCArg::Optional::NO, "Checkpoint data",
{
{"block_hash", RPCArg::Type::STR_HEX, RPCArg::Optional::NO, "Checkpoint block hash"},

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same

Comment thread src/rpc/quorums.cpp
@@ -1246,6 +1247,232 @@ static RPCHelpMan submitchainlock()
}


static RPCHelpMan getchainlockbyheight()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

let's maybe split this RPC (and chainlock index) into its own PR?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess we could; but we need the chain lock index to support this behavior

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🤖 Fix all issues with AI agents
In `@src/rpc/quorums.cpp`:
- Around line 1350-1358: The RPC help entries in the RPCResult construction use
snake_case keys (e.g., "quorum_proofs", "proof_hex") that don't match the actual
JSON output (camelCase like quorumProofs, proofHex, quorumPublicKey); update the
RPCResult key strings inside the RPC response description to use the camelCase
names emitted by the implementation (adjust entries such as
"quorum_proofs"->"quorumProofs", "proof_hex"->"proofHex", and any similar keys
like "quorum_public_key"->"quorumPublicKey"), and make the same changes for the
other occurrence mentioned (the block around the later entries referenced) so
the help text matches the real JSON field names.

In `@test/functional/feature_quorum_proof_chain.py`:
- Line 364: The log call uses an unnecessary f-string prefix in the message;
update the call to self.log.info by removing the leading "f" so the literal
string "Mining blocks to embed chainlock signatures..." is passed (locate the
self.log.info(...) invocation in the
test/functional/feature_quorum_proof_chain.py file and replace the f-string with
a plain string).
- Around line 329-337: Adjust the indentation of the two assert_raises_rpc_error
calls so they align correctly with the surrounding code to satisfy flake8 E128;
locate the lines calling self.nodes[0].getquorumproofchain with parameters
(checkpoint, checkpoint['chainlock_quorums'][0]['quorum_hash'], 999) and
(checkpoint, fake_hash, llmq_type) and re-indent the continued argument lines to
align under the first argument of each function call (keeping the same
arguments: checkpoint, quorum hash / fake_hash, llmq_type) so the wrapped
parameters are vertically aligned.
- Around line 12-14: Replace broad except Exception handlers used around RPC
"scan" calls with a specific except JSONRPCException to only catch the expected
"not found" RPC error; import JSONRPCException from test_framework.authproxy (or
the project's authproxy module) and in those except blocks bind the exception
(e.g., except JSONRPCException as e:) to assert or check the error message,
while re-raising or letting other unexpected exceptions propagate. Apply this
change to the handlers referenced (the import area and the try/except blocks
around the scan RPC in the ranges shown: the import block near the top and the
try/except blocks currently at 94-115 and 125-134), ensuring unexpected
exceptions are not swallowed.
♻️ Duplicate comments (1)
src/llmq/quorumproofs.cpp (1)

759-772: Bind the header to the chainlock’s signed block hash.

Right now the proof can pair a valid chainlock signature for block A with an unrelated header/merkle root for block B. Validate the header hash matches chainlock.blockHash before merkle proof checks.

🛠️ Suggested guard
         const CBlockHeader& header = proof.headers[proofIdx];
+        if (header.GetHash() != chainlock.blockHash) {
+            result.error = strprintf("Header does not match chainlock block hash in proof %d", proofIdx);
+            return result;
+        }
🧹 Nitpick comments (1)
src/llmq/blockprocessor.cpp (1)

606-634: Fast-path hash assumes SER_DISK == SER_GETHASH — add a guard/test.

If CFinalCommitment serialization ever diverges, this path would silently compute a different hash than SerializeHash and poison proofs. Consider a debug-only assertion or a unit test to lock the invariant. If you use the assert, add <cassert> if it's not already included.

🛠️ Suggested debug guard
-            return Hash(MakeByteSpan(ssValue).first(ssValue.size() - 32));
+            const uint256 fast_hash = Hash(MakeByteSpan(ssValue).first(ssValue.size() - 32));
+#ifdef DEBUG
+            auto [commitment, _] = GetMinedCommitment(llmqType, quorumHash);
+            if (!commitment.IsNull()) {
+                assert(fast_hash == ::SerializeHash(commitment));
+            }
+#endif
+            return fast_hash;

Comment thread src/rpc/quorums.cpp Outdated
Comment on lines +1350 to +1358
RPCResult::Type::OBJ, "", "",
{
{RPCResult::Type::ARR, "headers", "Block headers in the proof chain",
{{RPCResult::Type::OBJ, "", false, "Header object"}}},
{RPCResult::Type::ARR, "chainlocks", "Chainlock proofs",
{{RPCResult::Type::OBJ, "", false, "Chainlock entry"}}},
{RPCResult::Type::ARR, "quorum_proofs", "Quorum commitment proofs",
{{RPCResult::Type::OBJ, "", false, "Quorum proof entry"}}},
{RPCResult::Type::STR_HEX, "proof_hex", "Serialized proof (hex)"},

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

RPC help keys should match actual JSON output.

The response builders emit camelCase (quorumProofs, quorumPublicKey), but the help strings show snake_case. Update the help docs to avoid confusing callers.

🛠️ Suggested doc fix
-                {RPCResult::Type::ARR, "quorum_proofs", "Quorum commitment proofs",
+                {RPCResult::Type::ARR, "quorumProofs", "Quorum commitment proofs",
                     {{RPCResult::Type::OBJ, "", false, "Quorum proof entry"}}},
@@
-                {RPCResult::Type::STR_HEX, "quorum_public_key", /*optional=*/ true, "Verified public key (if valid)"},
+                {RPCResult::Type::STR_HEX, "quorumPublicKey", /*optional=*/ true, "Verified public key (if valid)"},

Also applies to: 1424-1429

🤖 Prompt for AI Agents
In `@src/rpc/quorums.cpp` around lines 1350 - 1358, The RPC help entries in the
RPCResult construction use snake_case keys (e.g., "quorum_proofs", "proof_hex")
that don't match the actual JSON output (camelCase like quorumProofs, proofHex,
quorumPublicKey); update the RPCResult key strings inside the RPC response
description to use the camelCase names emitted by the implementation (adjust
entries such as "quorum_proofs"->"quorumProofs", "proof_hex"->"proofHex", and
any similar keys like "quorum_public_key"->"quorumPublicKey"), and make the same
changes for the other occurrence mentioned (the block around the later entries
referenced) so the help text matches the real JSON field names.

Comment thread test/functional/feature_quorum_proof_chain.py Outdated
Comment on lines +329 to +337
# Test invalid LLMQ type
assert_raises_rpc_error(-8, "Invalid LLMQ type",
self.nodes[0].getquorumproofchain, checkpoint,
checkpoint['chainlock_quorums'][0]['quorum_hash'], 999)

# Test non-existent quorum hash
fake_hash = "0" * 64
assert_raises_rpc_error(-5, None,
self.nodes[0].getquorumproofchain, checkpoint, fake_hash, llmq_type)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Fix indentation to satisfy flake8 E128.

🧹 Suggested formatting fix
-        assert_raises_rpc_error(-8, "Invalid LLMQ type",
-            self.nodes[0].getquorumproofchain, checkpoint,
-            checkpoint['chainlock_quorums'][0]['quorum_hash'], 999)
+        assert_raises_rpc_error(-8, "Invalid LLMQ type",
+                                self.nodes[0].getquorumproofchain, checkpoint,
+                                checkpoint['chainlock_quorums'][0]['quorum_hash'], 999)
@@
-        assert_raises_rpc_error(-5, None,
-            self.nodes[0].getquorumproofchain, checkpoint, fake_hash, llmq_type)
+        assert_raises_rpc_error(-5, None,
+                                self.nodes[0].getquorumproofchain, checkpoint, fake_hash, llmq_type)
🧰 Tools
🪛 Flake8 (7.3.0)

[error] 331-331: continuation line under-indented for visual indent

(E128)


[error] 337-337: continuation line under-indented for visual indent

(E128)

🤖 Prompt for AI Agents
In `@test/functional/feature_quorum_proof_chain.py` around lines 329 - 337, Adjust
the indentation of the two assert_raises_rpc_error calls so they align correctly
with the surrounding code to satisfy flake8 E128; locate the lines calling
self.nodes[0].getquorumproofchain with parameters (checkpoint,
checkpoint['chainlock_quorums'][0]['quorum_hash'], 999) and (checkpoint,
fake_hash, llmq_type) and re-indent the continued argument lines to align under
the first argument of each function call (keeping the same arguments:
checkpoint, quorum hash / fake_hash, llmq_type) so the wrapped parameters are
vertically aligned.


# Mine extra blocks to embed chainlock signatures in cbtx
# This ensures the chainlock index has entries we can use for proof chains
self.log.info(f"Mining blocks to embed chainlock signatures...")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Remove stray f-string prefix.

🧹 Suggested formatting fix
-            self.log.info(f"Mining blocks to embed chainlock signatures...")
+            self.log.info("Mining blocks to embed chainlock signatures...")
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
self.log.info(f"Mining blocks to embed chainlock signatures...")
self.log.info("Mining blocks to embed chainlock signatures...")
🧰 Tools
🪛 Flake8 (7.3.0)

[error] 364-364: f-string is missing placeholders

(F541)

🪛 Ruff (0.14.13)

364-364: f-string without any placeholders

Remove extraneous f prefix

(F541)

🤖 Prompt for AI Agents
In `@test/functional/feature_quorum_proof_chain.py` at line 364, The log call uses
an unnecessary f-string prefix in the message; update the call to self.log.info
by removing the leading "f" so the literal string "Mining blocks to embed
chainlock signatures..." is passed (locate the self.log.info(...) invocation in
the test/functional/feature_quorum_proof_chain.py file and replace the f-string
with a plain string).

@github-actions

github-actions Bot commented Feb 3, 2026

Copy link
Copy Markdown

This pull request has conflicts, please rebase.

PastaPastaPasta added a commit to PastaPastaPasta/dash that referenced this pull request Mar 29, 2026
- Change ComputeSigningCommitmentIndex to return std::optional<size_t>
  to avoid silent fallback that could mis-attribute signers
- Remove unnecessary fallback path in BuildProofChain (migration ensures
  all historical commitments are indexed)
- Remove legacy header continuity check that incorrectly assumed
  consecutive blocks (headers are from commitment blocks spaced by DKG
  intervals)
- Add LLMQ type validation in verifyquorumproofchain RPC
- Use uint8_t for LLMQ type cast (matches enum class : uint8_t)
- Reduce cs_main lock scope using WITH_LOCK
- Fix /*optional=*/ syntax and RPC example placeholder
- Change int32_t to int for chainlockedHeight (style consistency)
- Update regression test for count mismatch validation
- Fix functional test params (5,3) and remove unnecessary delay

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
@PastaPastaPasta
PastaPastaPasta force-pushed the platform-sdk-compact-proof branch from e7a1527 to e4343c7 Compare March 29, 2026 15:16
@PastaPastaPasta
PastaPastaPasta force-pushed the platform-sdk-compact-proof branch from e4343c7 to 17297dc Compare March 29, 2026 16:20
@thepastaclaw

thepastaclaw commented Mar 30, 2026

Copy link
Copy Markdown
Collaborator

🕓 Queued for automated review — 5th in line, estimated start in ~1.7 h (commit c4b8ca1)
Estimated review time once started: ~40 min (two-phase automated review; median of recent runs).

  • Request priority review — tick this box and the review moves to the front of the queue.

@github-actions

Copy link
Copy Markdown

This pull request has conflicts, please rebase.

@thepastaclaw thepastaclaw left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

Two blocking issues in the new trustless quorum proof chain: (1) chainlock signature verification is deduplicated by height only, allowing an attacker to splice a forged ChainlockProofEntry that shares an nHeight with a legitimate entry and bypass BLS verification entirely; (2) the chainlock index is keyed only by chainlocked height and is unconditionally erased on disconnect, so reorgs can drop entries still referenced by active blocks. Several lower-severity concerns also need attention: the verifier ignores the checkpoint anchor and never links headers, signingQuorumType is taken from untrusted input, and a migration assert can abort the node on corrupt data.

🔴 2 blocking | 🟡 4 suggestion(s) | 💬 2 nitpick(s)

Comment thread src/llmq/quorumproofs.cpp Outdated
}

verifiedChainlockHeights.insert(chainlock.nHeight);
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Blocking: Duplicate-height ChainlockProofEntry bypasses BLS signature verification

VerifyProofChain deduplicates chainlock signature verification using std::set<int32_t> verifiedChainlockHeights keyed only on chainlock.nHeight. After a legitimate entry at height H is verified and inserted into the set, any subsequent ChainlockProofEntry that happens to share the same nHeight is skipped at line 726 and treated as authenticated even though its blockHash, signingQuorumHash, and signature were never checked.

Attack: an attacker constructs a proof containing (a) one legitimate ChainlockProofEntry at a real chainlocked height H (with the genuine signature observed on-chain) and (b) a forged ChainlockProofEntry also at height H with attacker-chosen blockHash = B_fake and a garbage signature. The forged entry passes verification because its height is already in the set. The attacker then supplies a header that hashes to B_fake (no PoW or parent linkage is checked at lines 760-767), a fabricated coinbase with a chosen merkleRootQuorums, and a merkle proof binding a forged CFinalCommitment with attacker-controlled quorumPublicKey to that root. VerifyProofChain returns valid = true with the attacker's chosen quorumPublicKey, completely defeating the trustless verification goal.

Fix: verify every distinct ChainlockProofEntry's BLS signature exactly once (e.g., iterate proof.chainlocks up front and verify each before processing quorumProofs), or dedupe on a key that includes (nHeight, blockHash, signingQuorumHash, signature). The forged-signature regression test only covers a single chainlock entry and would not catch this.

source: ['claude']

Comment thread src/evo/specialtxman.cpp Outdated
Comment on lines +745 to +751
// Remove chainlock index for this block's cbtx
if (block.vtx.size() > 0 && block.vtx[0]->nType == TRANSACTION_COINBASE) {
if (const auto opt_cbTx = GetTxPayload<CCbTx>(*block.vtx[0]); opt_cbTx && opt_cbTx->bestCLSignature.IsValid()) {
int chainlockedHeight = pindex->nHeight - static_cast<int>(opt_cbTx->bestCLHeightDiff) - 1;
m_quorum_proof_manager.RemoveChainlockIndex(chainlockedHeight);
}
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Blocking: RemoveChainlockIndex unconditionally erases entries still referenced by other blocks

IndexChainlock keys the index purely on chainlockedHeight (src/llmq/quorumproofs.cpp:217). Miners intentionally copy the same best ChainLock forward across consecutive coinbase transactions, incrementing bestCLHeightDiff until a newer ChainLock appears. ProcessSpecialTxsInBlock therefore overwrites the same DB slot from multiple consecutive blocks, and UndoSpecialTxsInBlock later calls RemoveChainlockIndex(chainlockedHeight) unconditionally on disconnect. After disconnecting block N+1, the previous block N (still in the active chain) may still embed a ChainLock for the same height, but the index entry is gone — getchainlockbyheight and BuildProofChain start reporting missing coverage until a newer ChainLock or a re-migration restores it.

Fix: either reference-count by cbtxBlockHash, only erase if the disconnected block's cbtxBlockHash matches the stored entry, or rebuild on next connect.

source: ['claude', 'codex']

Comment thread src/llmq/quorumproofs.cpp Outdated
qProof.commitment.quorumHash == expectedQuorumHash) {
targetProof = &qProof;
}
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: Verifier ignores checkpoint anchor and does not link headers across the chain

VerifyProofChain consumes only checkpoint.chainlockQuorumscheckpoint.blockHash and checkpoint.height are never consulted. Additionally, the headers carried by the proof are only validated for header.GetHash() == chainlock.blockHash; header.hashPrevBlock is never compared to the previous header or to the checkpoint hash. As written, any collection of individually valid chainlock-signed blocks can be spliced together regardless of whether they form a coherent chain or whether they lie on the same fork as the supplied checkpoint. This weakens the stated security property of verifying quorums "starting from a known checkpoint" and makes the checkpoint's blockHash/height effectively non-binding. Combined with the dedup bypass above this enables broader forgery, but even with that fixed the anchor should be enforced. Either require each headers[i+1].hashPrevBlock == headers[i].GetHash() (with the first header chained to checkpoint.blockHash) or document explicitly that the trust anchor is the quorum public key set only.

source: ['codex']

Comment thread src/llmq/quorumproofs.cpp Outdated
Comment on lines +741 to +749
// Build the SignHash for chainlock verification
// Chainlocks use: SignHash(llmqType, quorumHash, requestId, msgHash)
// where requestId = GenSigRequestId(height) and msgHash = blockHash
const uint256 requestId = chainlock::GenSigRequestId(chainlock.nHeight);
SignHash signHash{chainlock.signingQuorumType, chainlock.signingQuorumHash, requestId, chainlock.blockHash};

// Verify signature against the SignHash using non-legacy BLS scheme
const bool signatureVerified =
chainlock.signature.VerifyInsecure(signerPubKey, signHash.Get(), /*specificLegacyScheme=*/false);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: signingQuorumType taken from untrusted proof without bound to llmqTypeChainLocks

SignHash is constructed with chainlock.signingQuorumType directly from the proof, with no check that it equals Params().GetConsensus().llmqTypeChainLocks or matches the type recorded for the same signingQuorumHash in the checkpoint. While the BLS signature domain-separates by type and would normally bind it, accepting an attacker-controlled type opens cross-context replay if a checkpoint quorum public key ever signed a non-chainlock message whose SignHash parameters happen to coincide. Require chainlock.signingQuorumType == Params().GetConsensus().llmqTypeChainLocks (and ideally validate it against the matching checkpoint entry's quorumType).

source: ['claude']

Comment thread src/llmq/quorumproofs.cpp Outdated
Comment on lines +892 to +902
int32_t chainlockedHeight = pindex->nHeight - static_cast<int32_t>(cbtx.bestCLHeightDiff) - 1;
const CBlockIndex* pChainlockedBlock = pindex->GetAncestor(chainlockedHeight);
// This must be non-null - the height comes from validated blockchain data
assert(pChainlockedBlock);

IndexChainlock(
chainlockedHeight,
cbtx.bestCLSignature,
pindex->GetBlockHash(),
pindex->nHeight);
indexed_count++;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: MigrateChainlockIndex assert can abort node on malformed cbtx

int32_t chainlockedHeight = pindex->nHeight - static_cast<int32_t>(cbtx.bestCLHeightDiff) - 1; followed by assert(pChainlockedBlock); will abort the node if a historical cbtx has bestCLHeightDiff exceeding pindex->nHeight - 1 (e.g., from a corrupted block file). The active-chain path at src/evo/specialtxman.cpp:677-684 already uses if (pChainlockedBlock) defensively; mirror that here rather than asserting. A node should not crash during startup migration on rare corrupt data.

source: ['claude']

"Expected error about headers/proofs count mismatch, got: " + result.error);
}

BOOST_AUTO_TEST_SUITE_END()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: Missing regression test for duplicate-height chainlock dedup bypass

The PR description references a regression test discontinuous_headers_rejected that is not in the file — only trivially_passes, forged_chainlock_signature_rejected, and headers_proofs_count_mismatch_rejected are present. None covers the duplicate-nHeight ChainlockProofEntry case where two entries share the same height (one legitimate, one forged) — the exact case that bypasses signature verification today. Add a regression test for that scenario alongside the fix, and add a header-splicing test that demonstrates rejection when hashPrevBlock does not link to the previous header.

source: ['claude']

Comment thread src/llmq/quorumproofs.h Outdated
Comment on lines +239 to +241
// Maximum proof chain length (DoS protection)
// Limits how many intermediate quorums can be proven in a single chain
static constexpr size_t MAX_PROOF_CHAIN_LENGTH = 500;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💬 Nitpick: MAX_PROOF_CHAIN_LENGTH (500) contradicts PR description (50)

PR description states MAX_PROOF_CHAIN_LENGTH (50 quorums max) as the DoS bound, but the constant is 500. Either reduce the constant or update the description. 500 BLS verifications per RPC call on an unauthenticated endpoint is a non-trivial DoS budget and deserves explicit justification.

source: ['claude']

Comment thread src/llmq/quorumproofs.cpp Outdated
Comment on lines +542 to +543
int activeDuration = std::min(llmq_params.signingActiveQuorumCount * llmq_params.dkgInterval, 100);
int maxSearchHeight = std::min(WITH_LOCK(cs_main, return active_chain.Height()), pMinedBlock->nHeight + activeDuration);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💬 Nitpick: activeDuration hard cap of 100 is undocumented and may truncate valid searches

int activeDuration = std::min(llmq_params.signingActiveQuorumCount * llmq_params.dkgInterval, 100); caps the forward search at 100 blocks regardless of LLMQ params. For typical chainlock quorums (LLMQ_400_60 etc.) the natural product is much larger, so the cap can cause BuildProofChain to return std::nullopt when wider search would have succeeded (e.g., during cbtx-chainlock gaps). Either remove the magic 100 or document why it's an upper bound and make it a named constant.

source: ['claude']

@thephez thephez added the RPC Some notable changes to RPC params/behaviour/descriptions label Aug 25, 2026
@PastaPastaPasta PastaPastaPasta changed the title feat(llmq): add trustless quorum proof chain generation and verification feat(llmq): add mining-transaction snapshot proofs Sep 8, 2026
@PastaPastaPasta PastaPastaPasta changed the title feat(llmq): add mining-transaction snapshot proofs feat(rpc): add mining-transaction snapshot proofs Sep 8, 2026
@github-actions

github-actions Bot commented Sep 8, 2026

Copy link
Copy Markdown

This pull request has conflicts, please rebase.

Add bounded, request-local lookup of coinbase-carried ChainLocks on a fixed chain view. Locate certificates across repeated signatures and signing gaps without an index or startup scan. Missing block data remains an explicit error.

Cover lookup boundaries, shorter-chain requests, and unavailable disk data in the ChainLock unit suite.
Authenticate quorum handoffs with ChainLock certificates, complete mining transactions and positional Merkle paths. Bridge missing mining-height ChainLocks with bounded ancestor headers and authenticate final quorum and masternode roots from the coinbase.

Add commitment-only selection and bounded memoization keyed by verification inputs or exact chain history. Preserve per-request anchor, ancestry, inclusion, freshness and resource checks. Construction reads retained blocks on demand and accepts an archived or live final ChainLock.

Include real testnet roundtrip, malformed-input, tampering, warm-cache and concurrent verification tests. Historical-quorum honesty remains an explicit assumption; the proof does not replay DKG or full consensus.
Add getquorumproofchain and verifyquorumproofchain with CLI argument conversion,
strict request validation, independently supplied trust roots and bounded binary
responses. Optionally open one Platform quorum and eligible EvoNode records.

Use the existing ChainLock manager for a final live certificate that has not yet
appeared in a coinbase. Build against a fixed chain view outside cs_main and
check the final carrier or signed block is still active before returning.

Cover real fixture verification, malformed requests, freshness, restart,
CLI/HTTP argument handling and live-tip selection in functional tests. Include
release notes for the RPCs and retained-history requirements.
Include reproducible archive RPC measurements for uncached and memoized requests across history ranges, plus real Core-to-relay-to-SDK integration evidence on mainnet and testnet.

Retain the measured artifact hashes, historical source provenance, cold-file preparation utility and optional macOS profiling patch. Keep development activity notes excluded from version control.
@PastaPastaPasta
PastaPastaPasta force-pushed the platform-sdk-compact-proof branch from 1a327f9 to 7e7be9b Compare September 9, 2026 21:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

RPC Some notable changes to RPC params/behaviour/descriptions

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants